Skip to content

[#1063] Give back what the open reserved rather than what the configuration says by then, and ask for a restart when the cache size changes - #1066

Merged
vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/1063-cache-size-change-drifts-memory-quota
Sep 24, 2026
Merged

vharseko merged 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issues/1063-cache-size-change-drifts-memory-quota

Conversation

@vharseko

@vharseko vharseko commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Summary

PDBStorage and JEStorage reserve their cache size from the server's MemoryQuota when they open and release it
when they close - both times by reading the configuration they hold at that moment. A change of db-cache-size or
db-cache-percent on a running backend swaps that configuration (applyConfigurationChange ends in config = cfg)
without touching the quota or the cache, and neither property is marked as needing a restart. So the storage reserves
the old size and releases the new one at the next close - the disable an online import-ldif makes included - and
the quota drifts by the difference for the life of the JVM: a cache grown from 64 MB to 128 MB leaves the quota
believing 64 MB free that the server does not have; a shrink leaves 64 MB reserved by nobody. The running cache is
the old size throughout, and dsconfig reports the change applied.

Fixes #1063.

What changes

Both storages keep two numbers of their own instead of reading config twice:

  • configuredCacheSize - the cache size of the configuration the storage opened with (for PDB, what the buffer pool
    was built to);
  • reservedCacheSize - of it, what the quota granted. acquireMemory is a tryAcquire: refused, it reserves
    nothing, and the return value used to be ignored in both buildConfigurations, so a close released a size that
    was never taken. That is reachable without any change - the server does not call isConfigurationAcceptable for
    the backends it opens at startup.

close() gives back reservedCacheSize. isConfigurationChangeAcceptable admits a growth for the difference to
what is held rather than to config: once a change has been admitted but not applied, computeSize(config) is
already the new size while the reservation is the old one, and a second change was admitted for a difference nobody
would reserve. A size which does not grow past the one configured asks the quota for nothing, as before
(newSize <= max(reservedCacheSize, computeSize(config))): every change listener of the backend entry is asked about
every change, so after an open the quota refused - nothing held - a change of any other property, a disable, and the
disable TaskUtils.disableBackend makes for an online import, rebuild or restore would otherwise be refused.
applyConfigurationChange on an open storage whose cache size the change moves sets adminActionRequired and adds
NOTE_CONFIG_DB_CACHE_REQUIRES_RESTART (630), naming the size the backend was opened with and the one now
configured, both as the memory quota counts them: for a JE cache sized by db-cache-percent that is a percent of the
quota's reservable pool, not the cache JE runs, which JE takes as a percent of the maximum heap.

db-cache-size and db-cache-percent are marked component-restart in PDBBackendConfiguration.xml and
JEBackendConfiguration.xml, as db-directory is in the same files, so dsconfig says so too.

Why a restart rather than a resize

PersistIt sizes its buffer pool when the database opens and has no way to resize it (Persistit.setConfiguration
refuses a second configuration). JE could - je.maxMemory and je.maxMemoryPercent are mutable through
Environment.setMutableConfig - but JEStorage has never resized its environment, and it is not alone: none of the
JE properties the pluggable backend left without requires-admin-action is applied live since OPENDJ-1719 dropped
the setMutableConfig road of the old backend. Restoring that road for all of them is #1068; here the two
storages take the same shape, and the quota follows the cache that actually runs.

On master

The fix builds on the close() of #999 - the quota given back once, and since its last round after the database has
closed, in a finally - and on JEStorageTest, which #999 introduces. #999 is merged; the branch sits on master,
and the give-back of what the open reserved is the one inside that finally. Rebased onto master 0e039c6473
for round 1 of the review: the one conflict was JEStorageTest, where the replay cases of #1065 added their
imports and constants at the same places - a union, the test bodies merged on their own. #1070 (#1067) now asks
the quota in validateDbCacheSize instead of taking from it, leaving the reservation to the storage, as here.
Rebased onto master e333af0c8f for round 2 without a conflict (git range-diff: = for both earlier commits).
Three commits: the fix, round 1 and round 2 on top.

Tests

PDBStorageTest and JEStorageTest, thirteen cases each, over a mocked ServerContext with a fresh MemoryQuota:

  • aCacheGrownWhileOpenIsGivenBackAsItWasTaken, aCacheShrunkWhileOpenIsGivenBackAsItWasTaken - the quota is
    back where it started after open, change, close; the shrink asks for the restart as a growth does, with both sizes;
  • aCacheSizeChangedWhileOpenAsksForARestart - adminActionRequired, the message id and both sizes; a change
    back to the size opened with asks for nothing;
  • aChangeWhichLeavesTheCacheSizeAloneAsksForNothing - a change of another property asks for nothing, as before;
  • aCacheSizedByPercentAsksForARestartOnlyWhenThePercentChanges - db-cache-size 0 at percent 10: another
    property asks for nothing, percent 20 names memPercentToBytes(10) and memPercentToBytes(20);
  • aStorageWhichIsNotOpenAsksForNoRestart - a storage constructed but never opened;
  • aCacheSizeChangeIsAdmittedAgainstWhatTheStorageHolds - with 64 MB held and a change to 128 MB pending, 256 MB is
    refused and 192 MB admitted at 129 MB free;
  • aChangeWhichLeavesTheCacheSizeAloneIsAdmittedAfterARefusedReservation - nothing held, 32 MB free, a
    db-txn-no-sync change is admitted;
  • aGrowthAfterARefusedReservationIsMeasuredAgainstNothingHeld - same state, 80 MB is refused;
  • aShrinkIsAdmittedWithTheQuotaExhausted - 64 MB held, 0 free, 32 MB is admitted;
  • aReservationTheQuotaRefusedIsNotGivenBackOnClose - 32 MB free, a 64 MB cache opens, and the close leaves 32 MB;
  • aGrowthWithinWhatIsHeldAfterAShrinkAsksTheQuotaForNothing - opened at 128 MB, shrunk to 64 MB, 0 free: 96 MB is
    admitted;
  • aStorageWhichIsNotOpenAdmitsAChangeOfItsCachePercent - a storage constructed but never opened admits percent
    10 → 20.

Five of the six were red on the head of #999 before the fix (the sixth pins existing behaviour): +64 MB, −64 MB,
admission of 256 MB, no admin action, 96 MB after the close of a refused reservation.

Mutants, each run against both classes: close() releasing configuredCacheSize instead of the reserved size
(the refused-reservation case red, nothing else), admission against computeSize(config) (the admission case),
setAdminActionRequired dropped (the restart case), and the old release by config (grow, shrink and the refused
reservation). Round 1, on master 0e039c6473, one JVM per run: the admission of the first commit
(newSize <= reservedCacheSize), reservedCacheSize → configuredCacheSize in the admission, the short circuit
dropped, newCacheSize = cfg.getDBCacheSize(), the opened-with baseline moved after the note, and the open guard
dropped - each red on exactly its own case(s) in both classes. On the round head: PDBStorageTest 25/25,
JEStorageTest 22/22 (with the replay cases of #1065), FailedBackendOpenTest 8/8, BackendConfigManagerTestCase
11/11. Round 2, on master e333af0c8f, one JVM per run: the reservedCacheSize operand of the admission's max
dropped (IllegalArgumentException from a negative tryAcquire), != → > in the restart note, and
computeSize or the admission reading the memQuota field instead of the server context's quota (a
NullPointerException before the open) - each red on exactly its own case in both classes. On the round head:
PDBStorageTest 27/27, JEStorageTest 24/24 (reactor, -Pprecommit verify).

Regression set (one JVM per class, the fixed storages first on the classpath): FailedBackendOpenTest,
PDBTestCase, EncryptedPDBTestCase, JETestCase, EncryptedJETestCase, ReplayedConfigChangeTest,
OnDiskMergeImporterTest, PersistentCompressedSchemaTest, DN2IDTest, StateTest, ID2EntryTest,
ID2ChildrenCountTest, BulkCursorTest, DefaultIndexTest, ImportLDIFTestCase, RebuildIndexTestCase,
VerifyIndexTestCase, BackendConfigManagerTestCase - 240 tests, 0 failures (FailedBackendOpenTest and
PDBTestCase re-run after a collision on the admin port 65534 with another JVM on the machine).

Not in this PR

@vharseko

Copy link
Copy Markdown
Member Author

Rebased onto master now that #999 is merged (1af0a1247d): its last round moved the quota give-back of close() into a finally after env.close() / db.close(), and the give-back of what the open reserved sits inside that block now (JEStorage.java, PDBStorage.java) - git range-diff against the previous head shows that one hunk and nothing else. Head is 725ff9af7d, one commit on master; the "Stacked on #999" section of the description is rewritten accordingly.

Re-run green on the rebased head: PDBStorageTest (20), JEStorageTest (9), FailedBackendOpenTest (8), PDBTestCase / JETestCase (35 each), BackendConfigManagerTestCase (11).

@vharseko
vharseko force-pushed the issues/1063-cache-size-change-drifts-memory-quota branch from 725ff9a to 64f3cff Compare September 19, 2026 14:42
@vharseko

Copy link
Copy Markdown
Member Author

Rebased onto master once more, now that #994 is merged (739ea68b7c): 725ff9af7d → 64f3cffd00. The one conflict was the tail of backend.properties — 624-627 of #994 now sit before 630; the range-diff is clean apart from that context, and the commit is otherwise the same. JEStorageTest and PDBStorageTest on the new head: 29/29 green. Description updated ("On master").

@vharseko
vharseko force-pushed the issues/1063-cache-size-change-drifts-memory-quota branch from 64f3cff to bd9d9d1 Compare September 21, 2026 20:27
@vharseko

Copy link
Copy Markdown
Member Author

Rebased onto master (7cebc65e3c) now that #998, #1003, #1004, #1015 and #1016 are merged: 64f3cffd00 → bd9d9d133f. The one conflict was the tail of backend.properties again - 628-629 of #998 (WARN_INDEX_ADD_DISCARDED_LEFTOVER_TREES, ERR_CONFIG_INDEX_ATTRIBUTE_ALREADY_INDEXED) now precede 630, which keeps its ordinal; git range-diff against the previous head shows that context and nothing else. #998 also made JEStorage's constructor public, which merged with the changes here without a conflict and is in place on the new head.

The restack carries no change of its own, so the suites are not re-run for it; test-compile of the reactor on the new head is green, and CI runs on the head. Description updated ("On master").

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The quota now follows the cache that actually runs, instead of the configuration as it stands at close.

  • close() gives back reservedCacheSize in both PDBStorage and JEStorage, and buildConfiguration finally honours the result of acquireMemory (PDBStorage.java:1101, JEStorage.java:778).
  • computeSize() reads serverContext.getMemoryQuota() rather than the memQuota field, which is null until the first open.
  • The restart is declared in two places: component-restart in both XMLs, and adminActionRequired plus NOTE 630 in the ConfigChangeResult.

issue (blocking): After the quota refuses the open's reservation, isConfigurationChangeAcceptable refuses every change to the backend entry, including a change that leaves the cache size alone.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1556, opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1286

A refused reservation leaves reservedCacheSize == 0 while the storage stays open. From then on, newSize <= reservedCacheSize is false for every change. The check falls through to isMemoryAvailable(newSize), which asks for the same amount the quota just refused, and it adds no reason. ConfigurationHandler.replaceEntry asks every change listener on the entry, and nothing filters by property (ConfigurationHandler.java:620-627, ConfigChangeListenerAdaptor.java:339-345). So the following all fail with UNWILLING_TO_PERFORM and an empty reason:

  • a db-txn-no-sync change;
  • dsconfig set-backend-prop --set enabled:false;
  • the internal ds-cfg-enabled: false modify that TaskUtils.disableBackend makes for an online import-ldif, rebuild-index or restore (TaskUtils.java:203-210).

BASE accepted all of these through newSize <= computeSize(config). Startup opens are not checked against the quota, so this state needs no config change. It comes up with a third PDB/JE backend at the default db-cache-percent 50. It also comes up with one JE backend whose db-cache-size is more than half the reservable pool, because validateDbCacheSize has already taken that size and kept it (#1067). A server restart gets back to the same state.

    final long newSize = computeSize(newCfg);
    final MemoryQuota quota = serverContext.getMemoryQuota();
    // What does not grow past the size already configured asks the quota for nothing (BASE's rule);
    // a growth is measured against what this storage holds, which is what the next open adds to.
    return (newSize <= Math.max(reservedCacheSize, computeSize(config))
            || quota.isMemoryAvailable(newSize - reservedCacheSize))
        && checkConfigurationDirectories(newCfg, unacceptableReasons);

aCacheSizeChangeIsAdmittedAgainstWhatTheStorageHolds keeps its outcome under this fix: with 64 MB held and 128 MB pending, 256 MB is refused and 192 MB admitted at 129 MB free.

Pin: repeat the open of aReservationTheQuotaRefusedIsNotGivenBackOnClose (32 MB free, a 64 MB cache), then assert that a db-txn-no-sync-only change is acceptable. Do this in both classes. The assertion is red at this head.

    final PDBBackendCfg unchangedCache = createBackendCfg(SMALL_CACHE);
    when(unchangedCache.isDBTxnNoSync()).thenReturn(true);
    assertThat(storage.isConfigurationChangeAcceptable(unchangedCache, new ArrayList<LocalizableMessage>())).isTrue();

suggestion (non-blocking): Admission is only asserted where the reservation equals the configured size. A refused open, an unchanged size and a shrink are never checked.

opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java:638, :663, opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEStorageTest.java:311, :334

The only calls to isConfigurationChangeAcceptable come after a granted 64 MB open, where reservedCacheSize == configuredCacheSize. Two mutants survive all six cases in both classes (found by reading; not run):

  • reservedCacheSize replaced by configuredCacheSize in the admission line;
  • the newSize <= reservedCacheSize || short circuit dropped. In production that sends every shrink to Semaphore.tryAcquire(negative), which throws IllegalArgumentException.

The blocking issue above ships green for the same reason.

Pin: after the refused open of aReservationTheQuotaRefusedIsNotGivenBackOnClose, assert that createBackendCfg(SMALL_CACHE + SMALL_CACHE / 4) is not acceptable. It is 16 MB above what is configured but 80 MB above what is held, which kills the reserved → configured swap. Then, after a granted open with the quota drained to 0 free, assert that createBackendCfg(SMALL_CACHE / 2) is acceptable. That kills the dropped short circuit.


suggestion (non-blocking): The percent arm of the restart note is not pinned. Every case sizes the cache with db-cache-size.

opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java:616

Each applyConfigurationChange call in the class (:570, :586, :604, :624, :644) uses createBackendCfg(n * SMALL_CACHE). None goes through db-cache-percent, which is the shipped default (size 0, percent 50). The mutant newCacheSize = cfg.getDBCacheSize() at PDBStorage.java:1649 survives. On a percent-sized backend, that mutant makes every unrelated change set adminActionRequired and emit a note saying "X bytes … 0 bytes".

Pin: open with createBackendCfg(0) and getDBCachePercent() stubbed to 10. Assert that a db-txn-no-sync-only change at percent 10 leaves adminActionRequired() false, and that a change to percent 20 sets it and names memPercentToBytes(10) and memPercentToBytes(20).


suggestion (non-blocking): No test shows that the "opened with" baseline stays fixed across two changes.

opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java:598

Every restart-note case applies a single change to a storage that was just opened. So the mutant configuredCacheSize = newCacheSize; after the addMessage survives. In production, that mutant turns open 64 → change 128 → change back to 64 into a restart note for a pool that still runs at 64.

    final ConfigChangeResult back = storage.applyConfigurationChange(createBackendCfg(SMALL_CACHE));
    assertThat(back.adminActionRequired()).isFalse();
    assertThat(back.getMessages()).isEmpty();

Pin: append this to aCacheSizeChangedWhileOpenAsksForARestart in both classes.


suggestion (non-blocking): The open guard of the restart note (db != null / env != null) is not pinned.

opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEStorageTest.java:271, opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1650, opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1380

Every case that calls applyConfigurationChange opens the storage first. The mutant that drops the guard survives both classes. On a storage that has been constructed but never opened, the listener is already registered, and the mutant sets adminActionRequired and names "0 bytes opened with". The effect is small: the apply then fails in registerMonitoredDirectory anyway, at BASE as at this head.

Pin: construct a storage without opening it, apply a config with a different cache size, and assert that adminActionRequired() is false and that NOTE 630 is not among the messages.


issue (non-blocking): NOTE 630 calls the opened-with size "reserved", including when the quota refused the reservation.

opendj-server-legacy/src/messages/org/opends/messages/backend.properties:1173, opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1657, opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1387

Both callers pass configuredCacheSize into "the memory reserved for it … stay at the %d bytes". After a refused open the storage holds 0 bytes of the quota. A later cache change that the quota now admits therefore reports memory as reserved when none is. On the granted road the text is accurate.

NOTE_CONFIG_DB_CACHE_REQUIRES_RESTART_630=The change to the database cache of backend %s will not take effect \
 until the backend is restarted: the cache the backend runs with stays at the %d bytes it was opened with until \
 then, and the %d bytes now configured are reserved by the next open

@vharseko
vharseko force-pushed the issues/1063-cache-size-change-drifts-memory-quota branch from bd9d9d1 to cba9d51 Compare September 23, 2026 13:02
@vharseko

Copy link
Copy Markdown
Member Author

Thanks, all six taken. Round head: cba9d511b5, one commit on top of the PR commit, which I first rebased onto master 0e039c6473. The one conflict was JEStorageTest against #1065, which added its replay cases at the same imports and constants; I resolved it as a union, the test bodies merged on their own, and the commit message is unchanged. #1070 now asks the quota in validateDbCacheSize rather than taking from it, which leaves the reservation to the storage, as this PR assumes.

Blocking: a refused reservation refuses every change of the backend entry. Confirmed by reading. ConfigurationHandler.replaceEntry asks every change listener, and TaskUtils.disableBackend goes through it, so an online import-ldif, rebuild-index or restore on such a backend fails with UNWILLING_TO_PERFORM and an empty reason. I used your rule as is, in both storages (PDBStorage.isConfigurationChangeAcceptable, JEStorage.isConfigurationChangeAcceptable):

return (newSize <= Math.max(reservedCacheSize, computeSize(config))
        || quota.isMemoryAvailable(newSize - reservedCacheSize))
    && checkConfigurationDirectories(newCfg, unacceptableReasons);

The comment above it now explains why a change that does not grow the size asks the quota for nothing. aCacheSizeChangeIsAdmittedAgainstWhatTheStorageHolds keeps its outcome. The pin is aChangeWhichLeavesTheCacheSizeAloneIsAdmittedAfterARefusedReservation, in both classes.

Admission pins. aGrowthAfterARefusedReservationIsMeasuredAgainstNothingHeld (80 MB, nothing held, 32 MB free → refused) and aShrinkIsAdmittedWithTheQuotaExhausted (granted 64 MB open, quota drained to 0, 32 MB → admitted). The refused open is shared through openWithTheReservationRefused().

Percent arm. aCacheSizedByPercentAsksForARestartOnlyWhenThePercentChanges: open at db-cache-size 0 and percent 10. A db-txn-no-sync-only change asks for nothing. Percent 20 asks for a restart and names memPercentToBytes(10) and memPercentToBytes(20). createBackendCfg gained a (size, percent) overload for it.

Fixed baseline across two changes. Your snippet is appended to aCacheSizeChangedWhileOpenAsksForARestart: 64 → 128 → 64, and the second apply asks for nothing.

Open guard. aStorageWhichIsNotOpenAsksForNoRestart: a storage that was constructed but never opened. adminActionRequired() is false and no message carries the ordinal of NOTE 630. The apply still records the registerMonitoredDirectory failure you mentioned. The test does not assert on it and closes the storage in a finally.

NOTE 630. Reworded to your text. The number and the arguments are unchanged:

NOTE_CONFIG_DB_CACHE_REQUIRES_RESTART_630=The change to the database cache of backend %s will not take effect \
 until the backend is restarted: the cache the backend runs with stays at the %d bytes it was opened with until \
 then, and the %d bytes now configured are reserved by the next open

Runs. PDBStorageTest 25/25 and JEStorageTest 22/22 on the round head (reactor verify; the JE count includes the eight replay cases of #1065), plus FailedBackendOpenTest 8/8 and BackendConfigManagerTestCase 11/11. Mutants, one JVM per run, each against both classes:

mutant PDBStorageTest JEStorageTest
admission back to newSize <= reservedCacheSize (the previous head) aChangeWhichLeavesTheCacheSizeAloneIsAdmittedAfterARefusedReservation same
reservedCacheSize → configuredCacheSize in the admission aGrowthAfterARefusedReservationIsMeasuredAgainstNothingHeld same
short circuit dropped aChangeWhichLeavesTheCacheSizeAloneIsAdmittedAfterARefusedReservation, aShrinkIsAdmittedWithTheQuotaExhausted (IAE from tryAcquire) same
newCacheSize = cfg.getDBCacheSize() aCacheSizedByPercentAsksForARestartOnlyWhenThePercentChanges same
configuredCacheSize = newCacheSize after the note aCacheSizeChangedWhileOpenAsksForARestart same
db != null / env != null dropped aStorageWhichIsNotOpenAsksForNoRestart same

Each mutant is red on exactly the case(s) listed and green elsewhere.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: A refused reservation no longer turns into a refused disable, and the quota now follows what the open actually took.

  • PDBStorage.isConfigurationChangeAcceptable:1559-1560 / JEStorage.isConfigurationChangeAcceptable:1469-1470 admit a change that does not grow the size past Math.max(reservedCacheSize, computeSize(config)) without asking the quota. Only a growth is measured against what the storage holds. aChangeWhichLeavesTheCacheSizeAloneIsAdmittedAfterARefusedReservation pins this in both classes.
  • close() gives back reservedCacheSize (PDBStorage:1137, JEStorage:891), and a refused acquireMemory is now recorded as nothing held (PDBStorage:1101, JEStorage:842).

issue (non-blocking): For a JE backend sized by db-cache-percent, NOTE 630 prints the quota's estimate as "the cache the backend runs with", which is not the cache JE actually runs.

opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:839, :1563-1571; opendj-server-legacy/src/messages/org/opends/messages/backend.properties:1173-1175; opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/ConfigurableEnvironment.java:295

configuredCacheSize is memQuota.memPercentToBytes(percent), a percent of the quota's reservable pool ((e/π)² × Old Gen max). JE does not use that number. It sizes its own cache from je.maxMemoryPercent = db-cache-percent, which is a percent of Runtime.maxMemory. Take the default JE backend (size 0, percent 50) on -Xmx2g under G1: JE runs about 1 GiB, and a change to percent 40 reports that the cache "stays at the ~803,000,000 bytes it was opened with". On the percent arm the printed figure is always below JE's real cache. PDB builds its pool from configuredCacheSize (PDBStorage:1098), so PDB and a fixed-size JE backend report correctly. The quota arithmetic itself is consistent. For JE's percent arm, either name the configured percentage in the note, or describe the figure as the size the memory quota was asked for.


suggestion (non-blocking): No test covers the reservedCacheSize operand of the admission's Math.max.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1559, opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1469

In every admission call in both classes, max(reserved, config) == config. The mutant newSize <= computeSize(config) || …, which is BASE's shape, therefore stays green. aCacheShrunkWhileOpenIsGivenBackAsItWasTaken is the one case with reserved > config, and it closes without asking for admission. Under that mutant, a sequence of open at 128 MB, shrink to 64 MB, then change to 96 MB calls isMemoryAvailable(-32 MB). That goes to Semaphore.tryAcquire(-32) (MemoryQuota:86, :103), which throws IllegalArgumentException out of the change listener.

@Test
public void aGrowthWithinWhatIsHeldAfterAShrinkAsksTheQuotaForNothing() throws Exception
{
  closeAndRemove(storage);
  storage = new PDBStorage(createBackendCfg(2 * SMALL_CACHE), serverContext);
  storage.open(AccessMode.READ_WRITE);
  storage.applyConfigurationChange(createBackendCfg(SMALL_CACHE));

  assertThat(storage.isConfigurationChangeAcceptable(
      createBackendCfg(SMALL_CACHE + SMALL_CACHE / 2), new ArrayList<LocalizableMessage>())).isTrue();
}

Pin: the same case in JEStorageTest. Both are green at the head, and both throw IllegalArgumentException under the mutant.


suggestion (non-blocking): The restart note is asserted only after a growth, so a shrink while open could lose it with every case still green.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1654, opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1564; opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java:594, opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEStorageTest.java:289

Every NOTE 630 assertion follows either a growth (SMALL_CACHE → 2 * SMALL_CACHE, percent 10 → 20) or a return to the opened size. The one shrink in each class discards its ConfigChangeResult. So the mutant newCacheSize > configuredCacheSize passes both classes. Under it, a live shrink returns SUCCESS with no admin action while the cache keeps its opened size.

// aCacheShrunkWhileOpenIsGivenBackAsItWasTaken
final ConfigChangeResult ccr = storage.applyConfigurationChange(createBackendCfg(SMALL_CACHE));
assertThat(ccr.adminActionRequired()).isTrue();
assertThat(ccr.getMessages().get(0).toString()).isEqualTo(
    NOTE_CONFIG_DB_CACHE_REQUIRES_RESTART.get("PDBStorageTest", 2 * SMALL_CACHE, SMALL_CACHE).toString());

Pin: the same in JEStorageTest, using BACKEND_ID. It is red under != → >.


suggestion (non-blocking): The move from the memQuota field to serverContext.getMemoryQuota() is not pinned for a storage that is not yet open.

opendj-server-legacy/src/main/java/org/opends/server/backends/pdb/PDBStorage.java:1558, :1564-1568; opendj-server-legacy/src/main/java/org/opends/server/backends/jeb/JEStorage.java:1468, :1474-1478

memQuota is null from the constructor, which already registers the listener, until buildConfiguration runs. Every admission call in the tests is on an open storage, where the field and serverContext.getMemoryQuota() are the same instance. The one unopened case uses a fixed SMALL_CACHE, so computeSize never reaches the quota. If either method went back to memQuota, both classes would stay green. In production, that version would throw a NullPointerException for a percent-sized backend (the default) changed between BackendImpl.configureBackend:196 and openBackend:204.

@Test
public void aStorageWhichIsNotOpenAdmitsAChangeOfItsCachePercent() throws Exception
{
  final PDBStorage unopened = new PDBStorage(createBackendCfg(0L, 10), serverContext);
  try
  {
    assertThat(unopened.isConfigurationChangeAcceptable(
        createBackendCfg(0L, 20), new ArrayList<LocalizableMessage>())).isTrue();
  }
  finally
  {
    unopened.close();
  }
}

Pin: the same case in JEStorageTest. It is green at the head and throws NullPointerException if either method reads memQuota again.


nitpick (non-blocking): The javadoc of aStorageWhichIsNotOpenAsksForNoRestart says the change "is picked up by the open", but the apply fails before config = cfg.

opendj-server-legacy/src/test/java/org/opends/server/backends/pdb/PDBStorageTest.java:653, opendj-server-legacy/src/test/java/org/opends/server/backends/jeb/JEStorageTest.java:349-350

On a storage that was never opened, diskMonitor is still null, so registerMonitoredDirectory throws a NullPointerException. The catch turns it into an error result, and the next open uses the old configuration. The case still pins the open guard, which is what it is for. Reword the javadoc to say that a storage which is not open asks for no restart.

…han what the configuration says by then, and ask for a restart when the cache size changes

PDBStorage and JEStorage reserved their cache size from the memory quota by reading
config in buildConfiguration and released it by reading config again in close().
applyConfigurationChange swapped config in between without touching the quota or the
cache, and neither db-cache-size nor db-cache-percent was marked as needing a restart,
so a cache grown from 64 MB to 128 MB while the backend ran released 128 against 64
taken at the next disable - the one an online import makes included - and the quota
believed 64 MB free that the server did not have, for the life of the JVM; a shrink
left the difference reserved by nobody. The running cache was the old size throughout.

Both storages now keep two numbers of their own: the cache size of the configuration
they opened with, and of it what the quota granted - a tryAcquire it refused, which an
open at startup is not checked against, reserved nothing and used to be released all
the same. close() gives back the granted size. isConfigurationChangeAcceptable admits
the difference to what is held rather than to config, which a change admitted but not
yet applied has already moved to the new size. applyConfigurationChange on an open
storage whose cache size the change moves sets adminActionRequired and says so
(NOTE_CONFIG_DB_CACHE_REQUIRES_RESTART): PersistIt cannot resize a buffer pool once
the database is open, and JEStorage has never resized its environment. The two
properties are marked component-restart in both configuration XMLs, as db-directory is.

PDBStorageTest and JEStorageTest, six cases each: the grow and the shrink give back what
was taken, the change asks for a restart and names both sizes, a change which leaves
the cache alone asks for nothing, admission is against what is held, and a reservation
the quota refused is not given back.
…che past the configured size whatever the storage holds

After an open the quota refused, a storage holds nothing of the quota, and the admission
of the last commit measured every change against that nothing: newSize <= reservedCacheSize
failed for any cache, and isMemoryAvailable(newSize) asked for the very amount the quota
had just refused. Every change listener of the backend entry is asked about every change,
whatever property it moves, so a change of db-txn-no-sync, a disable, and the disable
TaskUtils.disableBackend makes for an online import-ldif, rebuild-index or restore were all
refused with UNWILLING_TO_PERFORM and no reason. The state needs no change of configuration
to reach: the server does not check the backends it opens at startup against the quota.

A size which does not grow past the one configured now asks the quota for nothing again, as
it did before this PR; a growth is still measured against what the storage holds, so the
case of a change admitted but not applied keeps its outcome. NOTE 630 no longer calls the
size the backend was opened with reserved, which after a refused open it is not.

PDBStorageTest and JEStorageTest, five more cases each and one extended, each killing a
mutant which survived both classes: a change which leaves the cache alone is admitted after
a refused reservation, a growth after it is measured against nothing held, a shrink is
admitted with the quota exhausted, a cache sized by percent asks for a restart only when
the percent moves, a storage which is not open asks for none, and a change back to the
size the storage opened with asks for nothing.
…s the memory quota counts it, and pin what the admission reads

For a JE backend sized by db-cache-percent, NOTE 630 printed the quota's count of the cache -
a percent of the quota's reservable pool, (e/pi)^2 of the old generation - as the cache the
backend runs with. JE sizes its cache from je.maxMemoryPercent against the maximum heap, so
the figure was always below the cache JE actually runs. The note now says that both figures
are what the memory quota counts: the size the backend was opened with until the restart, and
the size the next open reserves. PDB, which builds its pool from that count, and a JE backend
of a fixed size read the same as before.

PDBStorageTest and JEStorageTest, two more cases each and one extended, each killing a mutant
which survived both classes: a growth within what is held after a shrink asks the quota for
nothing (without the reserved operand of the admission's max, it asks the quota for a negative
amount and the semaphore throws), a shrink while open asks for the restart as a growth does,
and a storage which is not open admits a change of its cache percent (read from the field the
open sets, the size is a NullPointerException there). The javadoc of the case of a storage
which is not open no longer says the change is picked up by the open: the apply fails before
it replaces the configuration.
@vharseko
vharseko force-pushed the issues/1063-cache-size-change-drifts-memory-quota branch from cba9d51 to d5fd0c1 Compare September 24, 2026 11:57
@vharseko

Copy link
Copy Markdown
Member Author

Thanks for the review. Round 2 is d5fd0c1. The branch is first rebased onto master e333af0; the rebase is clean and git range-diff shows = for both earlier commits. All five points are taken.

issue - NOTE 630 on JE's percent arm. Confirmed: configuredCacheSize is memPercentToBytes(percent) of the quota's reservable pool, while JE sizes its cache from je.maxMemoryPercent against Runtime.maxMemory. I took your second option. The note no longer calls the figure the cache the backend runs with; it names both figures as the memory quota's count:

The change to the database cache of backend %s will not take effect until the backend is restarted: until then the cache keeps the size the backend was opened with, which the memory quota counts as %d bytes, and the next open reserves the %d bytes the quota counts for the new configuration

"Reserved" stays off the first figure, as agreed in round 1, because after a refused open nothing is held. The ordinal stays at 630. The javadoc of JEStorage.configuredCacheSize now says that for a percent-sized cache this is the quota's count, not the cache JE runs.

suggestion - the reservedCacheSize operand of Math.max. aGrowthWithinWhatIsHeldAfterAShrinkAsksTheQuotaForNothing is added in both classes. It is your case with one change: the quota is exhausted before the admission, so the case also shows that the growth asks the quota for nothing.

suggestion - NOTE after a shrink. aCacheShrunkWhileOpenIsGivenBackAsItWasTaken now asserts admin action and the exact NOTE 630 (2 * SMALL_CACHE → SMALL_CACHE) in both classes.

suggestion - quota of an unopened storage. aStorageWhichIsNotOpenAdmitsAChangeOfItsCachePercent is added in both classes, as you wrote it.

nitpick - javadoc. It now says that a storage which is not open runs no cache to restart, and that a change of the cache size asks it for none. The "picked up by the open" wording is gone.

Verification: PDBStorageTest 27/27 and JEStorageTest 24/24 are green at the head, in the reactor (-Pprecommit verify). I ran four mutants against each class, one JVM per class. Each fails only its own case:

Mutant PDB JE
Math.max(reserved, computeSize(config)) → computeSize(config) aGrowthWithinWhatIsHeld… (IllegalArgumentException) same
newCacheSize != configuredCacheSize → > aCacheShrunkWhileOpen… same
computeSize reads memQuota aStorageWhichIsNotOpenAdmits… (NullPointerException) same
admission reads memQuota aStorageWhichIsNotOpenAdmits… (NullPointerException) same

On the nitpick: the apply fails on an unopened storage because diskMonitor is null, and that window between configureBackend and openBackend already existed before this PR. I left it out of this PR's scope.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Round 2's points are all answered in both storages, and each one is pinned.

  • NOTE 630 now describes both figures as what the memory quota counts (backend.properties:1173-1175). The javadoc of JEStorage.configuredCacheSize (:736-738) explains why, on the percent arm, that figure differs from the cache JE actually runs.
  • Every round-2 test point now has a case in both classes. aGrowthWithinWhatIsHeldAfterAShrinkAsksTheQuotaForNothing (PDBStorageTest:795, JEStorageTest:490) covers the reservedCacheSize operand of the admission's Math.max. aStorageWhichIsNotOpenAdmitsAChangeOfItsCachePercent (PDBStorageTest:814, JEStorageTest:509) covers reading the quota through serverContext. aCacheShrunkWhileOpenIsGivenBackAsItWasTaken now asserts NOTE 630 after a shrink (PDBStorageTest:599, JEStorageTest:294).

@vharseko
vharseko merged commit 1aa253d into OpenIdentityPlatform:master Sep 24, 2026
17 checks passed
@vharseko
vharseko deleted the issues/1063-cache-size-change-drifts-memory-quota branch September 24, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug java Changes to Java sources tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A live change of db-cache-size drifts the memory quota: the storage reserves the old size at open and releases the new one at close

2 participants